<?php
/**
 * This file is part of php-server-class.
 * 
 * php-server-class is free software: you can redistribute it &&/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * php-server-class is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with php-server-class.  If not, see <http://www.gnu.org/licenses/>.
 * 
 * @package php-server-class
 * @author Rick Barenthin <dunst0@gmail.com>
 * @copyright Copyright (c) 2010, Rick Barenthin
 * @license http://www.gnu.org/licenses/ GNU General Public License v3
 * @version 0.0.4
 */
declare(encoding='UTF-8');
namespace Server;


/**
 * Server Klasse
 *
 * @since Version 0.0.1
 */
class Server implements IServer
{
    /** Host */
    protected $Host = IServer::HOST;
    /** Port */
    protected $Port = IServer::PORT;
    
    /** Log Stream */
    protected $LogStream = NULL;
    /** Access Log Stream */
    protected $AccessLogStream = NULL;
    /** Status des Servers */
    protected $Running = FALSE;
    
    /** Request Handler */
    protected $RequestHandler = NULL;
    /** Request Termination */
    protected $RequestTermination = NULL;
    /**
     * Maximale Anzahl von Requests
     *
     * Wert >= 0, aktiv
     * Wert = -1, deaktiviert
     */
    protected $MaxRequestCount = -1;
    
    /** Lausch Socket */
    protected $ListenSocket = NULL;
    /**
     * Clients Sockets
     *
     * Enthaelt (stdClass) socket, requestCount, lastRequest
     */
    protected $Clients = array();
    /**
     * KeepAlive TimeOut
     *
     * Wert >= 0, aktiv
     * Wert = -1, deaktiviert
     */
    protected $KeepAliveTimeOut = -1;
    
    /** Debug Output */
    protected $DebugOutput = FALSE;
    
    /**
     * Klassen Konstruktor
     * 
     * @param string $host Hostname oder IP
     * @param integer $port Portnummer
     * @param string $logPath Log Pfad
     * @since Version 0.0.1
     */
    public function __construct ($host = IServer::HOST, $port = IServer::PORT,
                                 $logPath = IServer::LOG_PATH,
                                 $accessLogPath = IServer::ACCESS_LOG_PATH)
    {
        //LogStream oeffnen
        $this->openLogStream($logPath);
        //AccessLogStream oeffnen
        $this->openAccessLogStream($accessLogPath);
        //Exception ueber LogStream ausgeben
        $this->setCustomExceptionHandler();
        
        //Host ueberpruefen
        if (preg_match(IServer::IP_PATTERN, $host))
            $this->Host = $host;
        else if (preg_match(IServer::IP_PATTERN, gethostbyname($host)))
            $this->Host = gethostbyname($host);
        else
            throw new \Exception('hostname or ip is not valid ', 1001);
        
        //Port ueberpruefen
        if (is_integer($port) && (($port >= 0) || ($port < 65535)))
            $this->Port = $port;
        else
            throw new \Exception('not a valid port number', 1002);
    }
    
    /**
     * Klassen Destruktor
     *
     * @since Version 0.0.1
     */
    public function __destruct ()
    {
        if ($this->Running)
            $this->stop();
        
        $this->closeAccessLogStream();
        $this->closeLogStream();
    }
    
    /**
     * Methode zum starten des Servers.
     * Danach lauscht der Server auf dem definierten Port
     * und bindet sich zu definierten IP.
     *
     * @since Version 0.0.1
     */
    public function start ()
    {
        $this->Running = TRUE;
        $this->writeToLog(iServer::L_MESSAGE, 'Server wurde started');
        
        //checkt ob der Request Handler vorhanden ist
        if ($this->RequestHandler === NULL)
            throw new \Exception('no request handler registered', 2001);
        
        //checkt ob der Request Termination vorhanden ist
        if ($this->RequestTermination === NULL)
            throw new \Exception('no request termination registered', 2002);
        
        //checkt ob entweder MaxRequestCount oder KeepAliveTimeOut gesetzt ist
        if (($this->MaxRequestCount < 0) && ($this->KeepAliveTimeOut < 0))
            throw new \Exception('either max request count or keep alive timeout must be set', 2003);
        
        $this->listen();
        $this->writeToLog(iServer::L_MESSAGE, 'Server lauscht { IP=' . $this->Host . '; Port=' . $this->Port . ' }' );
    }
    
    /**
     * Methode zum stopen den Server.
     *
     * @since Version 0.0.1
     */
    public function stop ()
    {
        $this->Running = FALSE;
        $this->writeToLog(iServer::L_MESSAGE, 'Server wurde gestopt');
        
        if ($this->ListenSocket !== NULL) {
            socket_close($this->ListenSocket);
        }
        
        foreach ($this->Clients as $client) {
            socket_shutdown($client);
            socket_close($client);
        }
    }
    
    /**
     * Methode die das Client Polling ausführt.
     * In einer Schleife aufgerufen,
     * realisiert sie das regelmaessige Socket Polling.
     *
     * @since Version 0.0.1
     */
    public function loop ()
    {
        $clientSockets = array();
        foreach ($this->Clients as $client)
            $clientSockets[] = $client->socket;
        
        $readSockets = array_merge((array)$this->ListenSocket, $clientSockets);
        $writeSockets = NULL;
        $exceptionSockets = $clientSockets;
        
        unset($clientSockets); //Not needed anymore - Clean Up
        
        $ready = socket_select($readSockets, $writeSockets, $exceptionSockets, SOCKET_POLL);
        
        if (($ready > 0) && (count($readSockets) > 0))
            $this->checkForNewClient($readSockets); //Add new client if exists
        
        foreach ($this->Clients as $key => $client) {
        
            $socketConnected = TRUE;
            $keepAlive = TRUE;
            
            if (($ready > 0) && (in_array($client->socket, $readSockets)))
                $keepAlive = $this->handleClientRequest($client, $socketConnected);
            
            if (! $this->checkClientConnection($client, $exceptionSockets, $keepAlive, $socketConnected))
                unset($this->Clients[$key]); // Client gone - CleanUp
        
        } //foreach end
    }
    
    /**
     * Methode mit der, die Request Handler Funktion registriert wird.
     *
     * @since Version 0.0.1
     * @param callback $callback Request Handler Funktion
     */
    public function registerRequestHandler ($callback)
    {
        if (is_callable($callback))
            $this->RequestHandler = $callback;
        else
            throw new \Exception('not a valid request handler', 3001);
    }
    
    /**
     * Methode mit der registiert wird was als Request Terminierung interpretiert werden soll.
     *
     * @since Version 0.0.1
     * @param mixed $termination Request Termination
     */
    public function setRequestTermination ($termination)
    {
        if (! empty($termination) && is_string($termination))
            $this->RequestTermination = $termination;
        else
            throw new \Exception('not a valid request termination', 4001);
    }
    
    /**
     * Methode mit der, die maximale Anzahl an Request festgelegt wird.
     *
     * @since Version 0.0.2
     * @param integer $maxRequestCount Maximale Anzahl an Request pro Socket
     */
    public function setMaxRequestCount ($maxRequestCount)
    {
        if (! empty($maxRequestCount) && is_integer($maxRequestCount))
            $this->MaxRequestCount = $maxRequestCount;
        else
            throw new \Exception('not a valid max request count', 4002);
    }
    
    /**
     * Methode mit der, das TimeOut des KeepAlive gesetzt wird.
     *
     * @since Version 0.0.2
     * @param integer $timeOut gewuenschtes Timeout des KeepAlive
     */
    public function setKeepAliveTimeOut ($timeOut)
    {
        if (! empty($timeOut) && is_integer($timeOut))
            $this->KeepAliveTimeOut = $timeOut;
        else
            throw new \Exception('not a valid keep alive timeout value', 4003);
    }
    
    /**
     * Debug Ausgaben anschalten
     *
     * @since Version 0.0.2
     */
    public function enableDebugOutput ()
    {
        $this->DebugOutput = TRUE;
    }
    
    /**
     * Status des Servers
     *
     * @since Version 0.0.4
     */
    public function serverRunning ()
    {
        return $this->Running;
    }
    
    
    /* ---- Protected Methods ------------------------------------------------------------------- */
    
    /**
     * Log Stream oeffnen
     *
     * @since Version 0.0.1
     * @param string $path Logfile Pfad oder leerer String
     */
    protected function openLogStream ($path)
    {
        if (! empty($path))
            $this->LogStream = fopen($path, 'a');
        else if (defined('STDOUT'))
            $this->LogStream = STDOUT;
        else
            $this->LogStream = fopen('php://output', 'w');
    }
        
    /**
     * Log Stream schliessen
     *
     * @since Version 0.0.1
     */
    protected function closeLogStream ()
    {
        if (! defined('STDOUT') || ($this->LogStream != STDOUT))
            fclose($this->LogStream);
    }
    
    /**
     * Access Log Stream oeffnen
     *
     * @since Version 0.0.1
     * @param string $path Logfile Pfad oder leerer String
     */
    protected function openAccessLogStream ($path)
    {
        if (! empty($path))
            $this->AccessLogStream = fopen($path, 'a');
        else if (defined('STDOUT'))
            $this->AccessLogStream = STDOUT;
        else
            $this->AccessLogStream = fopen('php://output', 'w');
    }
    
    /**
     * Access Log Stream schliessen
     *
     * @since Version 0.0.1
     */
    protected function closeAccessLogStream ()
    {
        if (! defined('STDOUT') || ($this->AccessLogStream != STDOUT))
            fclose($this->AccessLogStream);
    }
    
    /**
     * In den Log schreiben
     *
     * @since Version 0.0.1
     * @param string $type Typ der Log Meldung
     * @param string $text Text der Log Meldung
     */
     protected function writeToLog ($type, $text)
     {
        fprintf($this->LogStream, IServer::LOG_FORMAT . EOL, date(IServer::LOG_DATE_FORMAT), $type, $text);
     }
     
    /**
     * In den Access Log schreiben
     *
     * @since Version 0.0.1
     * @param string $ip IP des request Client
     * @param string $info Weitere Infos ueber den Client
     */
     protected function writeToAccessLog ($ip, $info)
     {
        fprintf($this->AccessLogStream, IServer::ACCESS_LOG_FORMAT . EOL, date(IServer::ACCESS_LOG_DATE_FORMAT), $ip, $info);
     }
    
    /**
     * Custom Exception Handler setzen
     *
     * @since Version 0.0.1
     */
    protected function setCustomExceptionHandler ()
    {
        $logStream = $this->LogStream;
        $logFormat = IServer::LOG_FORMAT . EOL;
        $logDateFormat = IServer::LOG_DATE_FORMAT;
        $logTypeException = iServer::L_EXCEPTION;
        
        //Exception Handler setzen
        set_exception_handler(
            function (\Exception $e) use ($logStream, $logFormat, $logDateFormat, $logTypeException )
            {
                fprintf($logStream, $logFormat, date($logDateFormat), $logTypeException, $e->getMessage());
            }
        );
    }
    
    /**
     * Port zum lauschen oeffnen
     *
     * @since Version 0.0.1
     */
    protected function listen ()
    {
        if (! $this->ListenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))
            throw new \Exception ('couldn\'t create listen socket' ,3001);
        
        socket_set_option($this->ListenSocket, SOL_SOCKET, SO_REUSEADDR, 1);
        
        if (! socket_bind($this->ListenSocket, $this->Host, $this->Port))
            throw new \Exception ('couldn\'t bind socket to host or port' ,3002);
        
        if (! socket_listen($this->ListenSocket))
            throw new \Exception ('couldn\'t listen on socket', 3003);
            
        socket_set_nonblock($this->ListenSocket);
    }
    
    /**
     * Vom Socket den kompletten Request lesen
     *
     * @since Version 0.0.2
     * @param resource $socket
     * @param bool $socketConnected
     * @return string rawRequest
     */
    protected function socketRead ($socket, &$socketConnected)
    {
        $socketConnected = TRUE;
        $requestFinished = FALSE;
        $rawRequest = '';
        while ($socketConnected && ! $requestFinished) {
            $readRequest = array($socket);
            $writeRequest = NULL;
            $exceptionRequest = array($socket);
            
            $tmp = '';
            
            if (socket_select($readRequest, $writeRequest, $exceptionRequest, 2)
                && in_array($socket, $readRequest))
                $tmp = socket_read($socket, 4096);
            else
                $tmp = FALSE;
            
            if ($tmp) {
                $rawRequest .= $tmp;
                if (strpos($tmp, $this->RequestTermination) !== FALSE)
                    $requestFinished = TRUE;
            }
            else
                $socketConnected = FALSE;
        } // while end
        
        return $rawRequest;
    }
    
    /**
     * Checkt ob ein neuer Client verbunden ist
     *
     * @since Version 0.0.3
     * @param array $readSockets
     */
    protected function checkForNewClient (&$readSockets)
    {
        if (($key = array_search($this->ListenSocket, $readSockets, TRUE)) !== FALSE) {
            if ($this->DebugOutput)
                $this->writeToLog(iServer::L_DEBUG, 'new client'); //DEBUG
            
            $readSockets[$key] = socket_accept($this->ListenSocket); //New Client
            $this->Clients[] = (object)array(
                'socket' => $readSockets[$key],
                'requestCount' => 0,
                'lastRequest' => time()
            );
        }
    }
    
    /**
     * Verarbeitet eine anfrage eines Clients
     *
     * @since Version 0.0.3
     * @param stdClass $client
     * @param bool $socketConnected
     * @return bool TRUE, wenn die Verbindung KeepAlive ist, sonst FALSE
     */
    protected function handleClientRequest(&$client, &$socketConnected)
    {
        $rawRequest = $this->socketRead($client->socket, $socketConnected);
        
        if ($socketConnected) {
            //Request Count erhoehen
            $client->requestCount++;
            //Last Request aktualisieren
            $client->lastRequest = time();
            
            //Handle Request
            $response = $this->executeRequestHandler($rawRequest);
            
            //Wirte Stuff to Access Log
            socket_getpeername($client->socket, $ip);
            $this->writeToAccessLog($ip, $response->accessInfo);
            
            //Send Response
            socket_write($client->socket, $response->response, strlen($response->response));
            
            return $response->keepAlive;
        }
        return FALSE;
    }
    
    /**
     * Checkt ob die Verbindung noch benötigt wird und schliest sie gegebenen falls.
     *
     * @since Version 0.0.3
     * @param stdClass $client
     * @param array $exceptionSockets
     * @param bool $keepAlive
     * @param bool $socketConnected
     * @return bool TRUE, wenn die Verbindung nicht geschlossen wurde, sonst FALSE
     */
    protected function checkClientConnection ($client, $exceptionSockets, $keepAlive, $socketConnected)
    {
        $noKeepAlive = $socketConnected && !$keepAlive;
        $keepAlivTimeOut = ($this->KeepAliveTimeOut > -1)
                            ? (time() - $client->lastRequest > $this->KeepAliveTimeOut)
                            : FALSE;
        $clientMaxRequests = ($this->MaxRequestCount > -1)
                             ? ($client->requestCount >= $this->MaxRequestCount)
                             : FALSE;
        
        if ($noKeepAlive || $keepAlivTimeOut || $clientMaxRequests) {
            //Client no longer need Connection / timeOut or max request reached
            if ($this->DebugOutput && $noKeepAlive)
                $this->writeToLog(iServer::L_DEBUG, 'no keepAlive'); //DEBUG
            else if ($this->DebugOutput && $keepAlivTimeOut)
                $this->writeToLog(iServer::L_DEBUG, 'keep Alive Timeout'); //DEBUG
            else if ($this->DebugOutput && $clientMaxRequests)
                $this->writeToLog(iServer::L_DEBUG, 'max requests'); //DEBUG

            $socketConnected = FALSE;
            socket_shutdown($client->socket);
        }
        
        if (! $socketConnected || in_array($client->socket, $exceptionSockets)) {
            if ($this->DebugOutput)
                $this->writeToLog(iServer::L_DEBUG, 'client gone - socket closed'); //DEBUG
            
            socket_close($client->socket);
            return FALSE;
        }
        
        return TRUE;
    }
    
    /**
     * Request Handler mit dem Raw Request aufrufen
     *
     * @since Version 0.0.4
     * @param string $rawRequest
     * @return array
     */
    public function executeRequestHandler($rawRequest)
    {
        if ($this->RequestHandler instanceof Closure)
            $result = $this->RequestHandler->__invoke($rawRequest);
        else
            $result = call_user_func($this->RequestHandler, $rawRequest);
        
        if (is_array($result))
            return (object) $result;
        else
            return $result;
    }
    
    /* ---- Private Methods --------------------------------------------------------------------- */
    
    // none
}

?>